Skip to content

fix(#186): maestro-interop — runner-leak reacquire + CDP re-pin, runFlow allowlist, route-drift detection#188

Merged
Lykhoyda merged 3 commits into
mainfrom
feat/issue-186-maestro-interop
May 30, 2026
Merged

fix(#186): maestro-interop — runner-leak reacquire + CDP re-pin, runFlow allowlist, route-drift detection#188
Lykhoyda merged 3 commits into
mainfrom
feat/issue-186-maestro-interop

Conversation

@Lykhoyda

Copy link
Copy Markdown
Owner

Addresses the high-value items of #186 (rn-dev-agent ↔ maestro-mcp iOS interop field report). Plan was cross-verified via a Gemini+Codex+Opus brainstorm. Full suite 1587 pass; tsc clean; P0 smoke-passed on the live Bridgeless app. All 3 commits GPG-signed.

P0 — driver conflict (#186 items 2 + 7), the ~91s cascade

Both tools drive the same iOS device via XCUITest; after a maestro_run the next device_snapshot saw the AgentDeviceRunner sentinel → ~44s full relaunch → then cdp_navigation_state hit a ~47s STALE_TARGET.

Both the daemon-leak and the maestro-eviction case surface as the same sentinel, so instead of trying to distinguish them (the brainstorm's !isSentinel heuristic was contradicted by the field telemetry, which labels the maestro case "agent-device-runner-leak"), a strictly-additive cheap recovery tier helps regardless of cause:

  • runner-leak-recovery.ts: new Tier-0 reacquire before attach-only/relaunch. When it clears the sentinel, recovery returns tier:'reacquire' with no relaunch.
  • device-session.ts: reacquireIosTargetApp = stopFastRunnersimctl launch booted (re-foreground, displacing the foreign session) → ensureFastRunner — mirrors repair-action's bringTargetAppToForeground (which already stops the runner first because "a prior maestro_run is still alive").
  • CDP re-pin: recovery sets a markCdpStale() flag; withConnection consumes it and proactively recoverFromStaleTargets (no-op when fresh) — converting the 47s catch-path timeout into a fast pre-handler re-pin.

Note: efficacy against a held maestro session is unverified without a live maestro+rn-dev-agent repro; the change is strictly additive (worst case = today's behavior + a few seconds), so it can't regress. Smoke test confirmed the modified withConnection + re-pin block are healthy on the live app.

P1 — runFlow allowlist (#186 item 1)

cdp_run_action hard-failed any saved action using runFlow ("Command not in allowlist"). Now allowed with secure inline-at-validation: nested commands get command-level allowlist checks; {file} refs are resolved (relative-only, no .., .yaml only, realpath-contained in flowRoot, depth-cap 5, cycle-reject) and expanded inline so the flow written to /tmp has no file refs. {file,when} → inline {when, commands} (semantics preserved).

P1 — route-drift detection (#186 item 3)

Auto-repair only handled SELECTOR_NOT_FOUND. A saved action that hit an inserted screen (the report's CouponCode between HomeAddress→PhoneNumber) would get its selector failure fuzzy-repaired — wrong. Now: M7 expectedRouteSequence field + new ROUTE_DRIFT code (3 enums) + pure nav-graph/route-sequence.ts helpers; run-action reclassifies a SELECTOR_NOT_FOUND as ROUTE_DRIFT (and skips repair) when the live route is off the expected sequence.

Tests

gh-186-reacquire-tier, gh-186-runflow-validator, gh-186-route-sequence, gh-186-route-drift-runaction (+ markCdpStale flag). Pure helpers tested directly (no socket), run-action via the DI seam.

Not in this PR (tracked follow-ups)

🤖 Generated with Claude Code

Lykhoyda and others added 3 commits May 30, 2026 19:54
#186 P0)

The maestro-mcp <-> rn-dev-agent driver conflict: both drive the same iOS device
via XCUITest, so after a maestro_run the next device_snapshot saw the
AgentDeviceRunner sentinel, did a ~44s full relaunch, then cdp_navigation_state
hit a ~47s STALE_TARGET — ~91s lost per occurrence.

Both the daemon-leak and the maestro-eviction case surface as the SAME sentinel,
so instead of trying to distinguish them (the brainstormed heuristic was
contradicted by the field telemetry, which labels the maestro case
"agent-device-runner-leak"), this adds a strictly-additive cheap recovery tier
that helps regardless of cause and falls back to today's behavior:

- runner-leak-recovery.ts: new Tier-0 `reacquire` (before attach-only/relaunch).
  Optional `reacquire` dep; when it clears the sentinel, recovery returns
  tier:'reacquire' WITHOUT closing the session or relaunching the app.
- device-session.ts: wires reacquireIosTargetApp = stopFastRunner -> simctl
  launch booted <appId> (re-foreground, displacing the foreign XCUITest session)
  -> ensureFastRunner(deviceId, appId). Mirrors repair-action's
  bringTargetAppToForeground (which already stops the runner first SPECIFICALLY
  because "a prior maestro_run is still alive"), kept local per that file's
  tight-dependency-surface rationale. Only wired with full iOS context
  (appId+deviceId); otherwise omitted so recovery falls back cleanly.
- CDP re-pin: device recovery sets a process-scoped markCdpStale() flag;
  withConnection consumes it before the next cdp_* handler and proactively
  recoverFromStaleTarget()s (no-op if actually fresh) — converting the 47s
  catch-path STALE_TARGET into a fast pre-handler re-pin.
- device-interact.ts: widened recoveredTier consumers to RecoveryTier.

Tests: gh-186-reacquire-tier.test.js (reacquire clears sentinel -> no relaunch;
fall-through reacquire->attach->relaunch order; no-dep unchanged; markCdpStale
read-and-clear). Full suite 1567 pass; tsc clean.

Note: efficacy against a *held* maestro XCUITest session is unverified without a
live maestro+rn-dev-agent repro; this change is strictly additive (worst case =
today's behavior + a few seconds before the existing relaunch), so it cannot
regress.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…P1)

cdp_run_action hard-failed any saved action using `runFlow` ("Command not in
allowlist") — commonly used for conditional dialog handling (deep-link "Open
in", Expo dev-client picker) — forcing a manual fallback to maestro-mcp.

maestro-validator.ts now allows `runFlow` with special handling:
- validateRunFlowValue: inline `commands` are validated as COMMANDS (recursive
  allowlist check), NOT via generic validateValue — which would let a nested
  `runScript` slip through as a plain scalar.
- expandRunFlows: {file} refs are securely resolved (relative-only, no `..`,
  .yaml/.yml only, realpath-contained within flowRoot — defeats symlink escape,
  depth cap 5, cycle detection) and EXPANDED INLINE so the flow written to /tmp
  has no remaining file refs (a relative ref would otherwise break there). A
  {file,when} becomes an inline conditional {when, commands} (semantics
  preserved); without `when` the sub-flow commands are spliced flat. A {file}
  ref with no flowRoot context is rejected.
- maestro-run.ts threads flowDir/flowRoot = dirname(flowPath) when running a
  saved flow file; inline YAML keeps file refs rejected.

Tests: gh-186-runflow-validator.test.js (inline allow + nested-denied reject;
file resolve+inline; traversal/absolute/symlink-escape/bad-ext/cycle/depth/
no-root rejects). Full suite 1577 pass; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n inserted screen (#186 P1)

cdp_run_action auto-repair only handled SELECTOR_NOT_FOUND (stale selectors). A
saved action that assumed HomeAddress -> PhoneNumber but hit a newly-inserted
CouponCode screen would have its selector failure fuzzy-repaired — wrong, since
the flow changed shape, not a testID.

- reusable-action.ts: new M7 `expectedRouteSequence` field (parse/serialize), and
  ROUTE_DRIFT added to ActionFailureCode + AutoRepairRefusedReason. types.ts:
  ROUTE_DRIFT ToolErrorCode.
- nav-graph/route-sequence.ts (pure, tested): validateRouteSequenceAgainstGraph
  (pre-flight definite-mismatch, conservative — no-ops on an empty/unknown graph)
  and classifyRouteDriftAfterFailure (live route off the expected sequence = drift).
- run-action.ts: after a SELECTOR_NOT_FOUND, if the action recorded an expected
  sequence and the live route (bounded best-effort fetch via injectable
  getLiveRoute) is off it, reclassify as ROUTE_DRIFT, persist it, and SKIP repair
  with an actionable "re-record the action" message.

Tests: gh-186-route-sequence (helper truth tables) + gh-186-route-drift-runaction
(off-sequence -> ROUTE_DRIFT + repair skipped; on-sequence -> repair still runs;
no-sequence -> inert). Full suite 1587 pass; tsc clean.

Remaining activation (separate slice, logic+contract complete here): populate
expectedRouteSequence at cdp_record_test_save_as_action time from nav events, and
wire a real CDP-backed getLiveRoute in index.ts (the default is a safe no-op).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Lykhoyda Lykhoyda merged commit d86b657 into main May 30, 2026
7 checks passed
Lykhoyda added a commit that referenced this pull request May 30, 2026
…189) (#190)

#188 merged the runFlow allowlist + non-destructive reacquire tier + route-drift
detection to main with NO version bump, so the fix was undeliverable to marketplace
installs and got re-reported as #189 on 0.44.44. Bump via changesets:
rn-dev-agent-plugin 0.44.44 -> 0.44.45, rn-dev-agent-cdp 0.38.39 -> 0.38.40.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lykhoyda added a commit that referenced this pull request May 30, 2026
…em) (#192)

#188 merged a behavior fix (runFlow allowlist) to main with NO version bump, so
it was undeliverable to marketplace installs and got re-reported as #189. Add a
PR-only CI job that fails when scripts/cdp-bridge/src/ changes without a changeset
— making the missing-bump class impossible rather than merely discouraged.

- scripts/require-changeset.sh: the guard (seams: CHANGED_FILES, REPO_ROOT, BASE_REF)
- scripts/test/require-changeset.test.sh: 4-case TDD regression test
- ci.yml: PR-only require-changeset job + run the guard unit test in the build job

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lykhoyda added a commit that referenced this pull request Jun 4, 2026
…ey, label scheme (#220)

Deep-research deliverable: apply the Kano model to prioritize the 16 open issues.

- 2026-06-04-kano-model-research-report.md — cited report (origins, 5 categories,
  functional/dysfunctional eval table, SI/DI, no-survey proxy adaptation, critiques,
  OSS triage). Includes a provenance caveat: the deep-research verify stage hit a
  StructuredOutput tooling failure (all claims defaulted to "refuted" via 0-0 abstain),
  so cited facts are manually vetted (canonical Kano), not machine-verified.
- kano-backlog-categorization.md — 16 issues categorized + Kano-gated priority waves;
  factors in that #202/#186/#201 largely shipped (#218/#188).
- kano-survey-template.md — functional/dysfunctional questionnaire tailored to the plugin.
- kano-label-scheme-and-triage.md — kano:* + effort:* labels, gh commands, monthly cadence.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Lykhoyda added a commit that referenced this pull request Jun 5, 2026
…r warning (#186) (#221)

* docs(202): Phase 3 design — 3-layer contract + proactive foreign-runner warning (#186)

Final #202 phase. Formalizes the §2 three-layer device-control contract
(L1 introspection shared / L2 interaction shared / L3 flow exclusive) in docs,
and adds a PROACTIVE foreign-runner detection warning.

Reconciliation (§0): the reactive #186 fix — runner-leak reacquire + CDP re-pin
(items 2+7), runFlow allowlist (1), route-drift (3) — already shipped in #188.
Phase 3 does NOT rebuild it. Scope trimmed to: (A) write down the §2 contract +
the #188 handoff behavior; (B) add detectIosExternalRunner + an informational
FOREIGN_RUNNER_ACTIVE warning on device_snapshot open, gated on no local flow
lease (else the WDA is ours). Items 4/5/6 deferred to follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(202): Phase 3 implementation plan — foreign-runner detection + contract docs

5 TDD tasks: detectIosExternalRunner, foreignRunnerNotice (flow-lease gate),
device-session wiring (informational FOREIGN_RUNNER_ACTIVE), the §2 contract
docs (CLAUDE.md + architecture.mdx + maestro-interop handoff page), changeset.
Scoped against #188 (no recovery rebuild).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(202): apply multi-LLM plan-review + live-ps-validation findings (Phase 3)

Amendments from the Gemini+Codex+Claude plan review and a live `ps` validation
(Task 0) run against a booted iPhone 17 Pro while a real maestro flow drove it:

- Matcher corrected: maestro's iOS driver is `maestro-driver-iosUITests-Runner`,
  NOT WebDriverAgent (kept only as a secondary token).
- UDID-scoping made mandatory: the idle maestro-mcp server carries no UDID, so
  an unscoped scan false-positives; detectIosExternalRunner gains a `udid` param
  and `line.includes(udid)` filter. New tests: different-sim ignored, idle
  server ignored, our RnFastRunner excluded.
- Wiring: thread deviceId as udid, add RN_IOS_FOREIGN_WARN opt-out, surface
  detector latency in meta.timings_ms.
- Teardown race documented (our own maestro driver shares the name); arbiter
  suppression-window deferred since action=open is not a post-flow hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#202): detectIosExternalRunner + foreignRunnerNotice — UDID-scoped iOS foreign-runner detection (Phase 3)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#202): surface FOREIGN_RUNNER_ACTIVE on iOS device-session open (Phase 3)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#202): formalize three-layer device-control contract + maestro-mcp handoff (Phase 3)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#202): changeset for Phase 3 foreign-runner contract + warning

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant